Skip to content

Fail fast when a scope-local registration binds its lifetime to an ancestor scope (#1460) - #1488

Merged
tillig merged 4 commits into
developfrom
feature/issue-1460
Jun 16, 2026
Merged

Fail fast when a scope-local registration binds its lifetime to an ancestor scope (#1460)#1488
tillig merged 4 commits into
developfrom
feature/issue-1460

Conversation

@tillig

@tillig tillig commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #1460. Registering InstancePerMatchingLifetimeScope("tag") inside a child scope's configuration lambda, where "tag" refers to a strict ancestor scope, caused a memory leak: the registration is local to the child (a fresh registration with a new GUID on every child creation, so instance sharing never hits), but its lifetime attaches to the ancestor — so every child hoists a new instance into the ancestor, and they accumulate until the ancestor is disposed.

Per the decision recorded in the issue thread, this configuration is logically contradictory (the lifetime is strictly longer than the registration's reachability) and is never useful, so we now fail fast rather than leak.

Fix

CreateScopeRestrictedRegistry now inspects the registrations added by the configuration action after they are materialized. For any registration whose lifetime is a MatchingScopeLifetime, if a matched tag belongs to a strict ancestor scope, it throws InvalidOperationException. This mirrors the existing CheckTagIsUnique / DuplicateTagDetected guard.

Boundaries that remain legal (and are covered by tests):

  • Matching the scope's own tag (lifetime == reachability).
  • Matching a future descendant tag (the intended use of matching lifetimes).
  • The normal "register in the builder, create the named scope later" path — it doesn't route through CreateScopeRestrictedRegistry and is unaffected.

The root container tag is always an ancestor, so binding to it from a child config action also throws.

Tests

Adds 5 tests: the exact repro (binding to an ancestor tag throws), own-tag is allowed, future-descendant tag is allowed, root tag throws, and a multi-tag case where one tag is an ancestor throws. Existing matching-scope/lifetime tests pass unchanged.

Adds resource string MatchingScopeLifetimeAncestorTag (internal). No public API changes. Zero warnings/errors; full suites pass on net8.0 and net10.0.

Note: this converts previously-silent (leaking) behavior into a thrown exception. It adds no API and only affects code that was already leaking.

)

When BeginLifetimeScope(tag, configAction) is used and the configuration
action registers a component with InstancePerMatchingLifetimeScope targeting
a strict ancestor scope tag, Autofac now throws InvalidOperationException at
scope-creation time instead of silently leaking instances into the ancestor.

Each such child scope creation would resolve and hoist a fresh component
instance into the ancestor scope (because the registration is only visible
inside the new child's local registry, which gets a new GUID on each
BeginLifetimeScope call and so sharing never deduplicates). Those instances
accumulate in the ancestor until the ancestor itself is disposed.

The guard is placed in CreateScopeRestrictedRegistry, after the configuration
action has been applied and the local tracker holds the explicit registrations.
It walks the current scope's ancestor chain to collect strict ancestor tags,
then checks each locally-registered MatchingScopeLifetime for any tag that
appears in that ancestor set. Matching the new scope's own tag or a future
descendant tag is still permitted.
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.00%. Comparing base (f8d4095) to head (f96a66f).
⚠️ Report is 13 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1488      +/-   ##
===========================================
+ Coverage    77.69%   78.00%   +0.30%     
===========================================
  Files          217      217              
  Lines         5829     5897      +68     
  Branches      1253     1265      +12     
===========================================
+ Hits          4529     4600      +71     
+ Misses         764      759       -5     
- Partials       536      538       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tillig added 3 commits June 15, 2026 16:39
- CheckLocalRegistrationsDoNotBindToAncestorScope now defers the HashSet
  allocation and parent-chain walk until a MatchingScopeLifetime registration
  is actually found, avoiding all work on the common (no matching-lifetime)
  child-scope path
- Tag issue #1460 in the guard method comment
- Rename test to two-segment scenario name (LocalRegistration_BindToGrandchildTag)
@tillig
tillig merged commit 0e724c2 into develop Jun 16, 2026
12 checks passed
@tillig
tillig deleted the feature/issue-1460 branch June 16, 2026 15:03
This was referenced Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak when registering InstancePerMatchingLifetimeScope() inside a BeginLifetimeScope lambda

1 participant